#!/usr/bin/env python3
'''
未验证

训练记录：
在腾讯云cloudstudio上训练
20241126：训练分数达到14分，测试分数达到52分，远低于conv，暂缓训练，如何调整
20241127：调整了网络，训练分数18分，测试分数54.8分，继续训练
20241128：训练分数12分，测试分数54.8分，毫无提升？是否需要继续训练？还是调整超参数？
20241129：训练分数32分，测试分数54.8分，继续训练，并确认学习率
20241202：调整学习率，重新训练，训练分数26分，测试分数58分，高于之前的分数，对比学习率，继续训练
20241203：训练分数22分，测试分数58分，评估学习率是否暂缓训练
20241204：莫名中断，测试分数77.2分，继续训练
20241205：训练分数50分，测试分数77.2分，继续训练
20241206：学习率0.0002657205，训练分数75分，测试分数142分，评估学习率继续训练
20241207：学习率0.0001937102445，训练分数86分，测试分数143分，评估学习率是否需要放缓，继续训练
20241208：学习率0.000141214，训练分数105分，测试分数181分，评估学习率是否需要放缓，继续训练
20241209：学习率9.26e-05，训练分数106分，测试分数231，评估学习率是否需要放缓，继续训练
20241211：学习率7.50473176484996e-05，训练分数124分，测试分数231分，评估学习率是否需要放缓，继续训练
20241212: 学习率6.078832729528468e-05，训练分数130分，测试分数234分，评估学习率是否需要放缓，继续训练
20241213：学习率4.431469059826253e-05，训练分数129分，测试分数239分，继续训练
20241214：学习旅3.988322153843628e-05，训练分数133分，测试分数277.6分，继续训练
20241215：学习率：3.230540944613339e-05，训练分数139分，测试分数327分，继续训练
20241216: 学习率：2.355e-05，训练分数141分，测试分数327分，继续训练
20241217: 学习率：1.9076021223847307e-05，训练分数150分，测试分数327分，继续训练
20241218：学习率：1.7168419101462575e-05，训练分数145分，测试分数378分，继续训练
20241219：学习率：1.3906419472184688e-05，训练分数147分，测试分数378分，继续训练
20241220：学习率： 1.251577752496622e-05，训练分数142分，测试分数378分，继续训练1天，无长进则停止训练
20241221: 学习率：1.0137779795222638e-05，训练分数146分，测时分数378分，停止训练，进步缓慢，没有继续训练的意义
'''
import gymnasium as gym
import ptan
import numpy as np
import argparse
from tensorboardX import SummaryWriter
import os

import torch
import torch.nn as nn
import torch.nn.utils as nn_utils
import torch.nn.functional as F
import torch.optim as optim
import ale_py


from lib import common

gym.register_envs(ale_py)
GAMMA = 0.99
LEARNING_RATE = 5e-4
ENTROPY_BETA = 0.01
BATCH_SIZE = 128
NUM_ENVS = 50
SAVE_ITERS = 100

REWARD_STEPS = 4
CLIP_GRAD = 0.5

class AtariA2C(nn.Module):
    def __init__(self, obs_size, n_actions):
        super(AtariA2C, self).__init__()

        self.conv = nn.Sequential(
            nn.Linear(obs_size, 512),
            nn.ReLU(),
            nn.Linear(512, 1024),
            nn.ReLU(),
            nn.Linear(1024, 1024),
            nn.ReLU(),
        )

        self.policy = nn.Sequential(
            nn.Linear(1024, 512),
            nn.ReLU(),
            nn.Dropout(p=0.2),
            nn.Linear(512, n_actions)
        )

        self.value = nn.Sequential(
            nn.Linear(1024, 512),
            nn.ReLU(),
            nn.Linear(512, 1)
        )

    def forward(self, x):
        linear_out = self.conv(x.float() / 255.0)
        return self.policy(linear_out), self.value(linear_out)
    

class RewardPenaltyWrapper(gym.Wrapper):
    def __init__(self, env, frame_penalty=-0.1, life_loss_penalty=-10):
        super(RewardPenaltyWrapper, self).__init__(env)
        self.frame_penalty = frame_penalty
        self.life_loss_penalty = life_loss_penalty
        self.previous_lives = 0

    def reset(self, **kwargs):
        obs, info = self.env.reset(**kwargs)
        self.previous_lives = info.get('lives', 0)  # 初始生命值
        return obs, info

    def step(self, action):
        obs, reward, done, truncated, info = self.env.step(action)

        reward //= 10 # 缩放奖励
        
        # 处理生命减少时的惩罚
        current_lives = info.get('lives', self.previous_lives)
        if current_lives < self.previous_lives:
            reward += self.life_loss_penalty
            self.previous_lives = current_lives
        
        return obs, reward, done, truncated, info
    

def wrap_dqn(env, episodic_life=True):
    if episodic_life:
        # 将多条生命的游戏模拟成单条生命ActorCriticAgent
        env = ptan.common.wrappers.EpisodicLifeEnv(env)
    # 增强初始化
    env = ptan.common.wrappers.NoopResetEnv(env, noop_max=30)

    if 'FIRE' in env.unwrapped.get_action_meanings():
        env = ptan.common.wrappers.FireResetEnv(env)
    env = RewardPenaltyWrapper(env)
    return env


def unpack_batch(batch, net, device='cpu'):
    """
    Convert batch into training tensors
    :param batch:
    :param net:
    :return: states variable, actions tensor, reference values variable
    """
    states = []
    actions = []
    rewards = []
    not_done_idx = [] # 非结束的游戏数据索引，该索引记录对应batch，states，actions，rewards
    last_states = [] # 记录采样中的执行动作后的状态，仅记录游戏非结束状态下的索引
    for idx, exp in enumerate(batch):
        states.append(np.array(exp.state, copy=False))
        actions.append(int(exp.action))
        rewards.append(exp.reward)
        if exp.last_state is not None:
            not_done_idx.append(idx)
            last_states.append(np.array(exp.last_state, copy=False))
    states_v = torch.FloatTensor(np.array(states, copy=False)).to(device)
    actions_t = torch.LongTensor(actions).to(device)
    rewards_np = np.array(rewards, dtype=np.float32)
    if not_done_idx:
        last_states_v = torch.FloatTensor(np.array(last_states, copy=False)).to(device)
        last_vals_v = net(last_states_v)[1]
        last_vals_np = last_vals_v.data.cpu().numpy()[:, 0]
        rewards_np[not_done_idx] += GAMMA ** REWARD_STEPS * last_vals_np

    ref_vals_v = torch.FloatTensor(rewards_np).to(device)
    return states_v, actions_t, ref_vals_v

def test_model(env, net, device, episodes=5):
    with torch.no_grad():
        total_reward = 0.0
        for _ in range(episodes):
            obs, _ = env.reset()
            while True:
                obs_v = ptan.agent.default_states_preprocessor([obs]).to(device)
                logits_v, _ = net(obs_v)
                probs_v = F.softmax(logits_v, dim=1)
                probs = probs_v.data.cpu().numpy()
                action = np.argmax(probs)
                obs, reward, done, trunc, _ = env.step(action)
                total_reward += reward
                if done or trunc:
                    break
    return total_reward / episodes


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--cuda", default=True, action="store_true", help="Enable cuda")
    parser.add_argument("-n", "--name", required=True, help="Name of the run")
    args = parser.parse_args()
    device = torch.device("cuda" if args.cuda else "cpu")

    save_path = os.path.join("saves", "a2c-linear-" + args.name)
    if not os.path.exists(save_path):
        os.makedirs(save_path)
    envs = [wrap_dqn(gym.make("ALE/BeamRider-ram-v5", frameskip=4, repeat_action_probability=0.0, obs_type="ram"), episodic_life=False) for _ in range(NUM_ENVS)]
    test_env = wrap_dqn(gym.make("ALE/BeamRider-ram-v5", frameskip=4, repeat_action_probability=0.0, obs_type="ram"), episodic_life=False)
    writer = SummaryWriter(comment="-a2c-linear-" + args.name)

    net = AtariA2C(envs[0].observation_space.shape[0], envs[0].action_space.n).to(device)
    print(net)

    agent = ptan.agent.PolicyAgent(lambda x: net(x)[0], apply_softmax=True, device=device)
    exp_source = ptan.experience.ExperienceSourceFirstLast(envs, agent, gamma=GAMMA, steps_count=REWARD_STEPS)
    optimizer = optim.Adam(net.parameters(), lr=LEARNING_RATE, eps=1e-3)
    scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=24000, gamma=0.9)


    start_idx = 0
    train_frame_idx = 0
     # 增加加载模型的代码
    if os.path.exists(save_path) and len(os.listdir(save_path)) > 0:
        # 增加加载模型的代码
        checkpoints = sorted(filter(lambda x: "epoch" in x, os.listdir(save_path)),
                             key=lambda x: int(x.split('_')[2].split('.')[0]))
        checkpoint = torch.load(os.path.join(save_path, checkpoints[-1]), map_location=device, weights_only=False)
        net.load_state_dict(checkpoint['net'])
        optimizer.load_state_dict(checkpoint['optimizer'])
        start_idx = checkpoint['start_idx']
        train_frame_idx = checkpoint['train_frame_idx']
        scheduler.load_state_dict(checkpoint['scheduler'])
        print("加载模型成功")
        #打印学习率
        print("scheduler last epoch: ", scheduler.last_epoch)
        print("Learning rate:", scheduler.get_last_lr()[0])

    batch = []
    best_reward = 0

    with common.RewardTracker(writer, stop_reward=10000) as tracker:
        with ptan.common.utils.TBMeanTracker(writer, batch_size=10) as tb_tracker:
            for step_idx, exp in enumerate(exp_source):
                batch.append(exp)

                new_rewards = exp_source.pop_total_rewards()
                if new_rewards:
                    if tracker.reward(new_rewards[0], step_idx + start_idx):
                        break

                if len(batch) < BATCH_SIZE:
                    continue

                states_v, actions_t, vals_ref_v = unpack_batch(batch, net, device=device)
                batch.clear()

                optimizer.zero_grad()
                logits_v, value_v = net(states_v)
                loss_value_v = F.mse_loss(value_v.squeeze(-1), vals_ref_v)

                log_prob_v = F.log_softmax(logits_v, dim=1)
                adv_v = vals_ref_v - value_v.squeeze(-1).detach()
                log_prob_actions_v = adv_v * log_prob_v[range(BATCH_SIZE), actions_t]
                loss_policy_v = -log_prob_actions_v.mean()

                prob_v = F.softmax(logits_v, dim=1)
                entropy_loss_v = ENTROPY_BETA * (prob_v * log_prob_v).sum(dim=1).mean()

                loss_policy_v.backward(retain_graph=True)
                grads = np.concatenate([p.grad.data.cpu().numpy().flatten()
                                        for p in net.parameters()
                                        if p.grad is not None])

                loss_v = entropy_loss_v + loss_value_v
                loss_v.backward()
                nn_utils.clip_grad_norm_(net.parameters(), CLIP_GRAD)
                optimizer.step()
                train_frame_idx += 1
                if train_frame_idx % 3 == 0:
                    scheduler.step()
                loss_v += loss_policy_v

                if train_frame_idx % 200 == 0:
                    net.eval()
                    test_reward = test_model(test_env, net, device=device, episodes=5)
                    net.train()
                    print(f"Test reward: {test_reward:.2f}")
                    common.save_best_model(test_reward, net.state_dict(), save_path, "a2c-linear-best", keep_best=10)

                if train_frame_idx % SAVE_ITERS == 0:
                    checkpoint = {
                        "net": net.state_dict(),
                        "optimizer": optimizer.state_dict(),
                        "start_idx": step_idx + start_idx,
                        "train_frame_idx": train_frame_idx,
                        "scheduler": scheduler.state_dict()
                    }
                    common.save_checkpoints(train_frame_idx, checkpoint, save_path, "a2c", keep_last=3)

                tb_tracker.track("advantage",       adv_v, step_idx + start_idx)
                tb_tracker.track("values",          value_v, step_idx + start_idx)
                tb_tracker.track("batch_rewards",   vals_ref_v, step_idx + start_idx)
                tb_tracker.track("loss_entropy",    entropy_loss_v, step_idx + start_idx)
                tb_tracker.track("loss_policy",     loss_policy_v, step_idx + start_idx)
                tb_tracker.track("loss_value",      loss_value_v, step_idx + start_idx)
                tb_tracker.track("loss_total",      loss_v, step_idx + start_idx)
                tb_tracker.track("grad_l2",         np.sqrt(np.mean(np.square(grads))), step_idx + start_idx)
                tb_tracker.track("grad_max",        np.max(np.abs(grads)), step_idx + start_idx)
                tb_tracker.track("grad_var",        np.var(grads), step_idx + start_idx)